Fork me on GitHub

React Hooks 指南

What

React v16.7.0-alpha 中第一次引入了 Hooks 的概念,在 v16.8.0 版本正式发布 React Hooks ,其带来的最大的变化在于给予了Function Component (函数式组件)类似组件生命周期的概念,扩大了 Function Component 的应用范围。

目前 Function Component 基本属于 dump 组件,Hooks 的出现使得 Function Component 有了自己的状态与业务逻辑,简单逻辑在自己内部处理即可,不再需要通过 Props 的传递,使简单逻辑组件抽离更加方便,也使使用者无需关心组件内部的逻辑,只关心 Hooks 组件返回的结果即可。

官方提供的 Hooks

  • 基本:useStateuseEffectuseContext
  • 额外:useCallbackuseReduceruseMemouseRefuseLayoutEffectuseImperativeHandleuseDebugValue

使用条件

  • 仅从 React 功能组件调用 hooks,不要从常规 js 函数中调用 hooks。
  • 只能在顶层调用,不能在 for 循环、if 条件判断、 *函数嵌套 *中使用。(Ps:React Hooks: 不是魔法只是数组 一文中有解释为什么 hooks 只能在顶层调用。)

环境条件

1、必须完整阅读一次React Hooks官方文档

英文文档:https://reactjs.org/docs/hooks-intro.html

中文文档:https://zh-hans.reactjs.org/docs/hooks-intro.html

其中重点必看hooks: useState、useReducer、useEffect、useCallback、useMemo

另外推荐阅读:

  1. Dan的《useEffect完全指南》
  2. 衍良同学的《React Hooks完全上手指南

2、工程必须引入lint插件,并开启相应规则

lint插件:https://www.npmjs.com/package/eslint-plugin-react-hooks

必开规则:

1
2
3
4
5
6
7
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

其中, react-hooks/exhaustive-deps 至少 warn,也可以是error。建议全新的工程直接配 “error”,历史工程配 “warn”。

切记,本条是硬性条件。如果你的工程,当前没开启 hooks lint rule,请不要编写任何 hooks 代码。

如果对于某些场景,确实不需要「exhaustive-deps」,可在代码处加:

1
// eslint-disable-next-line react-hooks/exhaustive-deps

3、如若有发现hooks相关lint导致的warning,不要全局autofix

除了hooks外,正常的lint基本不会改变代码逻辑,只是调整编写规范。但是hooks的lint规则不同,

exhaustive-deps 的变化会导致代码逻辑发生变化,这极容易引发线上问题,所以对于hooks的waning,请不要做全局autofix操作。除非保证每处逻辑都做到了充分回归。

另外建议开启vscode的「autofix on save」。未来无论是什么问题,能把error与warning 尽量遏制在最开始的开发阶段,保证自测跟测试时就是符合规则的代码

Why

React Hooks 主要解决以下三个主要的问题:

  • 代码重用:在 Hooks 出来之前,常见的代码重用方式是 HOC 和 render props,这两种方式带来的问题是:
    • 你需要解构自己的组件,非常的笨重,同时会带来很深的组件嵌套。
    • 难以重用和共享组件中的与状态相关的逻辑,造成产生很多巨大的组件。
  • 复杂的组件逻辑:在 class 组件中,有许多的 lifecycle 函数,当我们的组件需要处理多个互不相关的 local state 时,每个生命周期函数中可能会包含着各种互不相关的逻辑在里面,最终导致逻辑复杂的组件难以开发与维护。
  • class 组件的困惑:
    • 复杂的模式,如渲染道具和高阶组件。
    • 由于业务变动,函数组件不得不改为类组件

一个 React 项目,是由无数个大大小小的组件组合而成的。在 React 的世界中,组件是一等公民。而我们平时拆分组件的依据无非是:尽量的复用代码。组件是UI + 逻辑的复用,但逻辑复用能力等于 0。虽然 React 提供了 HOC 与 Render Props 两种方式来解决逻辑复用的问题,但由于 renderProps 嵌套问题等原因,这种解法并没有让逻辑复用流行起来。

React Hooks 很好的解决了逻辑复用的问题,同时还解决了状态共享的问题,是继 render-propshigher-order components 之后的第三种状态共享方案,且不会产生 JSX 嵌套地狱问题。

对于 Hooks、Render Props 和高阶组件来说,它们都有各自的使用场景:

  • Hooks:
    • 替代 Class 的大部分用例,除了 getSnapshotBeforeUpdatecomponentDidCatch 还不支持。
    • 提取复用逻辑。除了有明确父子关系的,其他场景都可以使用 Hooks。
  • Render Props:在组件渲染上拥有更高的自由度,可以根据父组件提供的数据进行动态渲染。适合有明确父子关系的场景。
  • 高阶组件:适合用来做注入,并且生成一个新的可复用组件。适合用来写插件。

How

useState

  • useState(0) 返回是个数组形式,useState(0) 代表 count 的初始值是 0,useState 现阶段只能传入一个初始值。useState 类似 setState,你可以看做是异步的,但 useState 必须保证执行顺序一一致,React 为每一次的 useState 调用分配一个空间,通过 useState 调用顺序辨别各个空间。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const Counter = () => {
    const [count, setCount] = useState(0);
    const increment = () => setCount(count + 1);
    return (
    <>
    <h1>{count}</h1>
    <button onClick={increment}>+</button>
    </>
    )
    }

useEffect

  • 如果我们需要在一些特定的生命周期或者值变化后做一些操作的话,必须借助 useEffect 的一些特性去实现。

  • useState 产生的 changeState 方法并没有提供类似于 setState 的第二个参数一样的功能,因此如果需要在 State 改变后执行一些方法,必须通过 useEffect 实现。

useEffect 的代码既会在初始化时候执行,也会在后续每次 rerender 时执行,接受两个参数:第一个参数为副作用需要执行的回调,生成的回调方法可以返回一个函数(将在组件卸载时运行);第二个为该副作用监听的状态数组,当对应状态发生变动时会执行副作用,如果第二个参数为空,那么在每一个 State 变化时都会执行该副作用。

使用 useEffect 实现类组件中生命周期:

  • useEffect 如果第二个参数数组中的成员变量为空,则表示与该副作用相关联的状态为空,不管其他状态如何变动,该副作用都不会再次执行,即实现了 effect 只会在组件 componentDidMountcomponentWillUnmout 时期执行。

    1
    2
    3
    useEffect(() => {
    // 每次componentDidMount会调用这里
    },[])
  • 如果需要在 componentWillUnmount 需要执行一些事件,可以 return 返回时候进行操作。

    1
    2
    3
    4
    5
    6
    useEffect(() => {
    //只有componentDidMount时候调用这里
    return () => {
    // componentWillUnmount
    };
    }, [])
  • useEffect 模拟 componentDidpdate,该生命周期在每次页面更新后都会被调用,那么可以利用 useEffect 如果第二个参数为空,那么在每一个 State 变化时都会执行该副作用的特性。

    1
    2
    3
    4
    5
    6
    7
    8
    const mounted = useRef();
    useEffect(() => {
    if(!mounted.current){
    mounted.current = true;
    } else {
    // 执行 componentDidpdate
    }
    })

useLayoutEffect

如果副作用是跟 DOM 相关的,需要使用 useLayoutEffect。useLayoutEffect 中的副作用会在 DOM 更新之后同步执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function App() {
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const title = document.querySelector('#title');
const titleWidth = title.getBoundingClientRect().width;
if (width !== titleWidth) {
setWidth(titleWidth);
}
});
return <div>
<h1 id="title">hello</h1>
<h2>{width}</h2>
</div>
}

useReducer

useReducer 接收两个参数,一个是 reducer 函数,跟 redux 中的 reducer 是一样的;另外一个是初始的状态值。返回的是一个数组,数组中的第一个元素是状态值,第二个元素是 dispatch 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const reducer = (state, action) => {
switch(action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
case 'reset':
return initState;
default:
return state;
}
};

function App() {
const [state, dispatch] = useReducer(reducer, initState);
return <div>
<h1>{state.count}</h1>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'reset'})}>reset</button>
</div>
}

useRef

useRef:在函数组件中获取组件或DOM节点的引用。

1
2
3
4
5
6
7
8
function App() {
const inputRef = useRef(null);

return <div>
<input type="text" ref={inputRef}/>
<button onClick={() => inputRef.current.focus()}>focus</button>
</div>
}

还可以用useRef来保存一些值的引用,并对它进行读写。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const useValues = () => {
const [values, setValues] = useState({});
const latestValues = useRef(values);

useEffect(() => {
latestValues.current = values;
});

const [updateData] = useCallback((nextData) => {
setValues({
data: nextData,
count: latestValues.current.count + 1,
});
}, []);

return [values, updateData];
};

在使用 ref 时要特别小心,因为它可以随意赋值,所以一定要控制好修改它的方法。特别是一些底层模块,在封装的时候千万不要直接暴露 ref,而是提供一些修改它的方法。

useContext、useReducer

Context 的作用就是对它所包含的组件树提供全局共享数据的一种技术。useReducer 结合 useContext,通过 context 把 dispatch 函数提供给组件树中的所有组件使用 ,而不用通过 props 添加回调函数的方式一层层传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 定义初始化值
const initState = {
name: '',
pwd: '',
isLoading: false,
error: '',
isLoggedIn: false,
}
// 定义state[业务]处理逻辑 reducer函数
function loginReducer(state, action) {
switch(action.type) {
case 'login':
return {
...state,
isLoading: true,
error: '',
}
case 'success':
return {
...state,
isLoggedIn: true,
isLoading: false,
}
case 'error':
return {
...state,
error: action.payload.error,
name: '',
pwd: '',
isLoading: false,
}
default:
return state;
}
}
// 定义 context函数
const LoginContext = React.createContext();
function LoginPage() {
const [state, dispatch] = useReducer(loginReducer, initState);
const { name, pwd, isLoading, error, isLoggedIn } = state;
const login = (event) => {
event.preventDefault();
dispatch({ type: 'login' });
login({ name, pwd })
.then(() => {
dispatch({ type: 'success' });
})
.catch((error) => {
dispatch({
type: 'error'
payload: { error: error.message }
});
});
}
// 利用 context 共享dispatch
return (
<LoginContext.Provider value={dispatch}>
<...>
<LoginButton />
</LoginContext.Provider>
)
}
function LoginButton() {
// 子组件中直接通过context拿到dispatch,出发reducer操作state
const dispatch = useContext(LoginContext);
const click = () => {
if (error) {
// 子组件可以直接 dispatch action
dispatch({
type: 'error'
payload: { error: error.message }
});
}
}
}

注:局部状态不推荐使用 useReducer ,会导致函数内部状态过于复杂,难以阅读。 useReducer 建议在多组件间通信时,结合 useContext 一起使用。

参考 react-redux

useMemo

当状态发生变化时,没有设置关联状态的 useEffect 会全部执行。同样的,通过计算出来的值或者引入的组件也会重新计算/挂载一遍,即使与其关联的状态没有发生任何变化

为了解决这个问题,引入了 useMemo 来实现类组件中 shouldComponetUpdate 的性能优化。 useMemo 接受两个参数,第一个参数为一个 Getter 方法,返回值为要缓存的数据或组件,第二个参数为该返回值相关联的状态,当其中任何一个状态发生变化时就会重新调用 Getter 方法生成新的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React, { useState, useMemo } from 'react';
import { message } from 'antd';

export default function HookDemo() {
const [count1, changeCount1] = useState(0);
const [count2, changeCount2] = useState(10);

const calculateCount = useMemo(() => {
message.info('重新生成计算结果');
return count1 * 10;
}, [count1]);
return (
<div>
{calculateCount}
<button onClick={() => { changeCount1(count1 + 1); }}>改变count1</button>
<button onClick={() => { changeCount2(count2 + 1); }}>改变count2</button>
</div>
);
}

useCallback

useCallback 用户生成 Callback ,配合useEffect 使用,抽离不同 useEffect 中存在的相同逻辑的封装,减少代码冗余。

useCallback 的使用方法和 useEffect 一致,第一个参数为生成的回调方法,第二个参数为该方法关联的状态,任一状态发生变动都会重新生成新的回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const [count1, changeCount1] = useState(0);
const [count2, changeCount2] = useState(10);

const calculateCount = useCallback(() => {
if (count1 && count2) {
return count1 * count2;
}
return count1 + count2;
}, [count1, count2])

useEffect(() => {
const result = calculateCount(count, count2);
message.info(`执行副作用,最新值为${result}`);
}, [calculateCount])

Custom Hooks

举个🌰,自定义监听窗口大小组件:

1
2
3
4
5
6
7
8
// 一个显示目前窗口大小的组件
function responsiveComponent(){
// custom hooks
const width = useWindowWidth();
return (
<p>当前窗口的宽度是 {width}</p>
)
}

自定义 useWindowWidth, 窗口调整大小时使用副作用来设置状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState, useEffect} from 'react';
// custom hooks to listen window width change
function useWindowWidth(){
const [width, setWidth] = useState(window.innerWidth);

useEffect(() => {
const handleResize = ()=>{
setWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
}, [width]); // width 没有变化则不处理

return width;
}

注: custom hooks 还可以参考一个有意思的 hooks 库 react-use

原理

useState 底层实现

React Hooks: 不是魔法,只是数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
let state = [];
let setters = [];
let firstRun = true;
let cursor = 0;

function createSetter(cursor) {
return function setterWithCursor(newVal) {
state[cursor] = newVal;
};
}

// This is the pseudocode for the useState helper
export function useState(initVal) {
if (firstRun) {
state.push(initVal);
setters.push(createSetter(cursor));
firstRun = false;
}

const setter = setters[cursor];
const value = state[cursor];

cursor++;
return [value, setter];
}

// Our component code that uses hooks
function RenderFunctionComponent() {
const [firstName, setFirstName] = useState("Rudi"); // cursor: 0
const [lastName, setLastName] = useState("Yardley"); // cursor: 1

return (
<div>
<Button onClick={() => setFirstName("Richard")}>Richard</Button>
<Button onClick={() => setFirstName("Fred")}>Fred</Button>
</div>
);
}

// This is sort of simulating Reacts rendering cycle
function MyComponent() {
cursor = 0; // resetting the cursor
return <RenderFunctionComponent />; // render
}

console.log(state); // Pre-render: []
MyComponent();
console.log(state); // First-render: ['Rudi', 'Yardley']
MyComponent();
console.log(state); // Subsequent-render: ['Rudi', 'Yardley']

// click the 'Fred' button

console.log(state); // After-click: ['Fred', 'Yardley']

Hooks 下的数据流管理

社区有一个非常轻量的库 unstate-next ,可以做到管理全局的 state :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React, { useState } from "react"
import { createContainer } from "unstated-next"
import { render } from "react-dom"

function useCounter(initialState = 0) {
let [count, setCount] = useState(initialState)
let decrement = () => setCount(count - 1)
let increment = () => setCount(count + 1)
return { count, decrement, increment }
}

let Counter = createContainer(useCounter)

function CounterDisplay() {
let counter = Counter.useContainer()
return (
<div>
<button onClick={counter.decrement}>-</button>
<span>{counter.count}</span>
<button onClick={counter.increment}>+</button>
</div>
)
}

function App() {
return (
<Counter.Provider>
<CounterDisplay />
<Counter.Provider initialState={2}>
<div>
<div>
<CounterDisplay />
</div>
</div>
</Counter.Provider>
</Counter.Provider>
)
}

render(<App />, document.getElementById("root"))

参考文章